TypeScript Types as a Programming Language: Unlocking Advanced Capabilities
Explore how TypeScript's type system functions as a powerful programming language, enabling advanced techniques like generics, conditional types, recursion, and mapped types for robust and dynamic type definitions.

Did you know TypeScript is Turing complete? This post will explore type definitions as if they were programs.
The objective isn't to create games or perform complex math operations—these impressive feats have already been accomplished:
- TypeScript types can run DOOM
- TypeScript Meets Math: Arithmetic Operations At The Type Level Because Why Not?
While these examples are remarkable, they often prompt the question: Why?
Our goal here is to improve our ability to write effective types by approaching them as programs.
Generic Types: The Function
In TypeScript, you can define types that depend on other types, functioning much like programming language functions. For a given input type, they produce an output type.
// The simplest function: identity
const identity = (value) => value;
// becomes a generic type
type Identity<Type> = Type;
// and we can "call" it like this
type Result = Identity<number>;
// type Result = number
// A more complex one
const createObject = (a, b) => ({ a, b });
// becomes a generic type
type CreateObject<A, B> = {
a: A;
b: B;
};
// and can be "called" like this
type Result = CreateObject<string, boolean>;
// type Result = { a: string; b: boolean }
Adding Type Constraints to Your Generic Type Functions
The extends keyword allows you to specify that a type parameter must extend a given type, similar to typing function arguments.
type CreateObject<A extends string, B> = {
a: A;
b: B;
};
// A must be a string, otherwise a type error occurs
type Errored = CreateObject<number, boolean>;
// Type 'number' does not satisfy the constraint 'string'
type Result = CreateObject<'name', boolean>;
// type Result = { a: "name"; b: boolean; }
Adding Default Types to Your Generic Type Functions
Just like with function parameters, you can provide a default type for your generic type function.
type CreateObject<
Key extends string = 'defaultName',
Value = string
> = {
[Key]: Value
};
// Key and Value are now optional
type Result = CreateObject;
// type Result = { defaultName: string }
type Result2 = CreateObject<'name'>;
// type Result2 = { name: string; }
type Result3 = CreateObject<'name', boolean>;
// type Result3 = { name: boolean; }
A Real-World Example: A Simple CRUD Creator
Using these concepts, we can create a generic CRUD type generator:
type Crud<Resource extends { id: string | number }> = {
// Create takes all fields except 'id'
create: (resource: Omit<Resource, 'id'>) => Resource;
// Read takes an 'id' and returns a resource or undefined
getOne: (id: Resource['id']) => Resource | undefined;
// Update takes an 'id' and a partial resource (without 'id')
// and returns a resource or undefined
update:
(id: Resource['id'], resource: Partial<Omit<Resource, 'id'>>)
=> Resource | undefined;
// Delete takes an 'id' and returns a boolean indicating success
delete: (id: Resource['id']) => boolean;
// List takes a partial resource (without 'id') as a filter
// and returns an array of resources
getList: (filter: Partial<Omit<Resource, 'id'>>) => Resource[];
}
// Example usage
type User = {
id: number;
name: string;
email: string
};
type UserCrud = Crud<User>;
// type UserCrud = {
// create: (resource: Omit<User, 'id'>) => User;
// getOne: (id: number) => User | undefined;
// update: (id: number, resource: Partial<Omit<User, 'id' >>) => User | undefined;
// delete: (id: number) => boolean;
// getList: (filter: Partial<Omit<User, 'id'>>) => User[];
// }
Conditions: Conditional Types
If we have functions, can we also have conditions? Yes, by using the extends keyword to create conditions within type functions. extends tests if a type is assignable to another type, followed by ternary syntax: condition ? trueCase : falseCase.
type IsNumber<Value extends unknown> = Value extends number ? true : false;
type Result = IsNumber<7>;
// type Result = true
type Result2 = IsNumber<'seven'>;
// type Result = false
A Real-World Example: Retrieving Event Type from an Event
type CreateEvent = {
type: "create";
payload: { name: string }
};
type UpdateEvent = {
type: "update";
payload: { id: number; name?: string }
};
type DeleteEvent = {
type: "delete";
payload: { id: number }
};
type UnknownEvent = unknown;
type Event = CreateEvent | UpdateEvent | DeleteEvent | UnknownEvent;
type InferEventType<T extends Event> =
T extends CreateEvent
? "create"
: T extends UpdateEvent
? "update"
: T extends DeleteEvent
? "delete"
: never;
// Example usage
type EventType = InferEventType<UpdateEvent>;
// 'update'
type EventType2 = InferEventType<CreateEvent>;
// 'create'
type EventType3 = InferEventType<DeleteEvent>;
// 'delete'
type EventType4 = InferEventType<'An event'>;
// never
The infer Keyword: The Variable
The infer keyword allows you to declare a variable within a type definition. It also supports destructuring.
// Use 'infer' to destructure the first element of an array and return it
type First<Element> =
Element extends [infer FirstElement, ...any[]]
? FirstElement
: never;
// Example usage
type Result = First<[string, number, boolean]>;
// type Result = string
type Result2 = First<[]>;
// type Result2 = never
A Real-World Example
With infer, the InferEventType example from the Conditional Types section can be simplified:
type InferEventType<T extends Event> =
T extends { type: infer EventType }
? EventType
: never;
// Here, we define a variable 'EventType' that extracts the 'type' field from 'T'.
// No need to check each event type separately.
// Example usage
type EventType = InferEventType<UpdateEvent>;
// 'update'
type EventType2 = InferEventType<CreateEvent>;
// 'create'
type EventType3 = InferEventType<DeleteEvent>;
// 'delete'
type EventType4 = InferEventType<{}>;
// never
Recursive Types: How to Loop
A type can call itself, enabling the creation of recursive types. This is useful for typing recursive data structures, such as a tree:
type TreeNode<Value> = {
value: Value;
children?: TreeNode<Value>[];
}
type StringTree = TreeNode<string>;
const tree: StringTree = {
value: 'root',
children: [
{ value: 'child1' },
{
value: 'child2',
children: [
{ value: 'grandchild1' }
]
}
]
};
Recursion can also be used to iterate over an array. Let's implement a Find type that searches for a ValueType within an ArrayType. It returns the ValueType if found, otherwise never:
type Find<ArrayType extends unknown[], ValueType> =
// Destructure the array into its first element and the rest
ArrayType extends [infer First, ...infer Rest]
? // Check if the first element matches the type we are looking for
First extends ValueType
? // If it does, return it
First
: // Otherwise, recurse on the rest of the array
Find<Rest, ValueType>
: // If the array is empty, return never
never;
// Example usage with events
type EventsArray = [
{ type: 'create'; payload: { name: string; }; },
{ type: 'update'; payload: { id: number; name?: string; }; },
{ type: 'delete'; payload: { id: number; }; }
];
type CreateEvent = Find<EventsArray, { type: 'create'; payload: { name: string; }; }>;
// type CreateEvent = { type: "create"; payload: { name: string; }; }
type UpdateEvent = Find<EventsArray, { type: 'update'; payload: { id: number; name?: string; }; }>;
// type UpdateEvent = {
// type: "update";
// payload: { id: number; name?: string | undefined; };
// }
type DeleteEvent = Find<EventsArray, { type: 'delete'; payload: { id: number; }; }>;
// type DeleteEvent = { type: "delete"; payload: { id: number; }; }
type UnknownEvent = Find<EventsArray, { type: 'unknown'; payload: {}; }>;
// type UnknownEvent = never
Real-World Example: Typing Middleware Results
This recursive Find type can be particularly useful in systems where middlewares modify operation results based on specific arguments. If you know the middleware types, you can determine the result type for a given argument:
// Middleware type:
// takes an argument of type Arg and returns a result of type Result
type Middleware<Arg, Result> = (arg: Arg) => Result;
// Example middlewares
type Middlewares = [
Middleware<
{ type: "getList"; withComments: true },
{ id: number; name: string; comments: string[] }[]
>,
Middleware<
{ type: "getList" },
{ id: number; name: string }[]
>,
Middleware<
{ type: "getOne"; withComments: true },
{ id: number; name: string; comments: string[] }
>,
Middleware<
{ type: "getOne" },
{ id: number; name: string }
>
];
// Find the middleware with an argument matching type Target
type FindMiddleware<Target> = Find<Middlewares, Middleware<Target, any>>;
// Get the result type of a middleware
type GetResult<MiddlewareInput extends Middleware<any, any>> =
MiddlewareInput extends Middleware<any, infer Result>
? Result
: never;
// Putting all pieces together
type GetMiddlewareResult<Arg> = GetResult<FindMiddleware<Arg>>;
type ListResult = GetMiddlewareResult<{ type: "getList" }>;
// type ListResult = { id: number; name: string; }[]
type OneResult = GetMiddlewareResult<{ type: "getOne" }>;
// type OneResult = { id: number; name: string; }
type ListWithCommentsResult = GetMiddlewareResult<
{ type: "getList"; withComments: true; }
>;
// type ListWithCommentsResult = {
// id: number;
// name: string;
// comments: string[]
// }[]
type OneWithCommentsResult = GetMiddlewareResult<
{ type: "getOne"; withComments: true; }
>;
// type OneWithCommentsResult = {
// id: number;
// name: string;
// comments: string[]
// }
String Manipulation: Template Literal Types
TypeScript allows string manipulation at the type level using template literals. You can perform simple string concatenation:
type HelloWorld<Greeted extends string = 'world'> = `Hello ${Greeted}`;
type DefaultResult = HelloWorld;
// 'Hello world'
type Result = HelloWorld<'TypeScript'>;
// 'Hello TypeScript'
You can also achieve more complex string manipulations, such as removing all spaces from a string using the infer keyword and recursion:
type RemoveWhitespace<S extends string> =
S extends `${infer First} ${infer Rest}`
? `${First}${RemoveWhitespace<Rest>}`
: S;
type Result = RemoveWhitespace<'Hello World !'>;
// type Result = "HelloWorld!"
Real-World Example: Generating Getter Method Names from Property Names
type Getter<Key extends string> = `get${Capitalize<Key>}`;
type Result = Getter<'name'>;
// type Result = "getName"
type Result2 = Getter<'firstName'>;
// type Result2 = "getFirstName"
Note: Capitalize is a built-in utility type that capitalizes the first letter of a string type. You can implement it yourself using Uppercase and recursion:
type Capitalize<S extends string> =
S extends `${infer First}${infer Rest}`
? `${Uppercase<First>}${Rest}`
: S;
// Uppercase is also a utility type but is implemented as compiler intrinsic magic.
// Example usage
type Result = Capitalize<"hello">;
// type Result = "Hello"
Mapped Types
While recursion allows us to loop over arrays, how do we iterate over object properties? Mapped types provide a solution by letting you create a new type through transforming each property of an existing type. They are built upon indexed access types and the keyof operator.
Indexed Access Types
Indexed access types retrieve the type of an object property using the syntax Type[Key]:
type User = {
id: number;
name: string;
email: string
};
type UserId = User['id'];
// type UserId = number
type UserName = User['name'];
// type UserName = string
They can also be used with a union of keys to obtain a union of the types of those keys:
type UserIdOrName = User['id' | 'name'];
// type UserIdOrName = number | string
keyof operator
The keyof operator returns the union of all keys of a type.
type User = {
id: number;
name: string;
email: string
};
type UserKeys = keyof User;
// type UserKeys = "id" | "name" | "email"
in keyword
By combining the keyof operator with the in keyword, we can iterate over all keys of a type:
type UserPropertiesAsString = {
// Here 'K' represents each key of 'User',
// and we map over it to create a function
// that returns the type of the property (User[K])
[K in keyof User]: () => User[K];
};
// type UserPropertiesAsString = {
// id: () => number;
// name: () => string;
// email: () => string;
// }
With this, you can create a type that transforms all methods of an object to return a Promise.
type Promisify<R extends Record<string, (...args: any) => any>> = {
[K in keyof R]: (...args: Parameters<R[K]>) => Promise<ReturnType<R[K]>>;
};
type Input = {
getName: () => string;
getById: (id: string) => { id: string; name: string };
};
type PromisifiedInput = Promisify<Input>;
// type PromisifiedInput = {
// getName: () => Promise<string>;
// getById: (id: string) => Promise<{ id: string; name: string; }>;
// }
Real-World Example
Here’s a more complex example of a mapped type: Let's take a resource type and a list of filter keys to generate getByFilterType methods for each filter key.
type Product = {
id: number;
name: string;
quantity: number;
inStock: boolean;
};
type CrudWithFilters<
Resource extends { id: string | number },
FilterKeys extends keyof Resource
> = {
// getByFilter for each filter key
[K in FilterKeys as `getBy${Capitalize<string & K>}`]:
(value: Resource[K]) => Resource[];
}
type ProductCrudWithFilters =
CrudWithFilters<Product, 'name' | 'quantity' | 'inStock'>;
// type ProductCrudWithFilters = {
// getByName: (value: string) => Product[];
// getByQuantity: (value: number) => Product[];
// getByInStock: (value: boolean) => Product[];
// }
// Example usage with actual functions
const productCrud: CrudWithFilters<
Product,
'name' | 'quantity' | 'inStock'
> = {
getByName: (name: string) => [
{ id: 1, name, quantity: 10, inStock: true }
],
getByQuantity: (quantity: number) => [
{ id: 2, name: 'Product 2', quantity, inStock: false }
],
getByInStock: (inStock: boolean) => [
{ id: 3, name: 'Product 3', quantity: 5, inStock }
],
};
// The type of 'productCrud' is correctly inferred,
// and you will get type errors if your function
// implementation does not match the expected type.
const erroredProductCrud: CrudWithFilters<
Product,
'name' | 'quantity' | 'inStock'
> = {
getByName: (name: number) => [
{ id: 1, name: 'Product 1', quantity: 10, inStock: true }
],
// Type '(name: number) => { id: number; name: string; quantity: number; inStock: true; }[]'
// is not assignable to type '(value: string) => Product[]'.
// Types of parameters 'name' and 'value' are incompatible.
// Type 'string' is not assignable to type 'number'.
getByQuantity: (quantity: string) => [
{ id: 2, name: 'Product 2', quantity: 5, inStock: false }
],
// Type '(quantity: string) => { id: number; name: string; quantity: number; inStock: false; }[]'
// is not assignable to type '(value: number) => Product[]'.
// Types of parameters 'quantity' and 'value' are incompatible.
// Type 'number' is not assignable to type 'string'
getByInStock: (inStock: boolean) => 'not found',
// Type '(inStock: boolean) => string'
// is not assignable to type '(value: boolean) => Product[]'.
// Type 'string' is not assignable to type 'Product[]'.
};
Reducing an Object of Functions to a Single Function
With keyof, we can return types based on an object's shape, not just an object itself. For instance, here’s a type definition that transforms an object of functions into a single function. This function takes the object key as its first argument and the original function's arguments as the rest.
// First, let's define our helpers:
// ObjectToFunc takes any object of functions
// and converts it into a single function
type ObjectToFunc<
Obj extends Record<string, (...args: any) => any>
> = {
// It maps over the keys of the object argument
// and for each one defines a function.
<K extends keyof Obj>(
key: K,
...args: Parameters<Obj[K]>
): ReturnType<Obj[K]>;
};
// This results in a union type of functions for each object key.
type Example = ObjectToFunc<{
getStringLength: (s: string) => number;
isEven: (n: number) => boolean;
}>;
// type Example =
// | (key: "getStringLength", s: string) => number
// | (key: "isEven", n: number) => boolean;
// Now let's create the actual function
const transformObjectToFunction = <
Obj extends Record<string, (...args: any) => any>
>(
obj: Obj
): ObjectToFunc<Obj> => {
return ((key: string, ...args: unknown[]) => {
const func = obj[key];
return func(...args);
}) as ObjectToFunc<Obj>;
};
// Example usage: combining a collection of getList functions
// for different resources into a single getList function
const getList = transformObjectToFunction({
authors: (filter: { name?: string }) => [
{ id: 1, name: "Author 1" },
{ id: 2, name: "Author 2" },
],
posts: (filter: { authorId?: number; published?: boolean }) => [
{ id: 1, title: "A post" },
],
comments: (filter: { postId?: number; authorId?: number }) => [
{ id: 1, content: "A comment" },
],
});
getList("posts", { authorId: 1 });
// No error
// Inferred as:
// const getList: <"posts">(
// key: "posts",
// filter: { authorId?: number; published?: boolean; }
// ) => { id: number; title: string; }[]
getList("posts", { name: 'Author 1' });
// Error: Object literal may only specify known properties,
// and 'name' does not exist in type:
// { authorId?: number | undefined; published?: boolean | undefined; }
getList("authors", { name: "Author 1" });
// No error
// Inferred as:
// const getList: <"authors">(
// key: "authors",
// filter: { name?: string | undefined; }
// ) => { id: number; name: string; }[]
Note: The previous example utilizes ReturnType and Parameters, which are utility types provided by TypeScript.
-
ReturnTypereturns the type of the value returned by a given function type. If curious, it can be reimplemented as:
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any; ```
-
Parametersreturns the arguments of a given function type. It can be reimplemented as:
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never; ```
Conclusion
By treating TypeScript type definitions as a programming language, it's possible to define advanced generic types. These generic types, in turn, enable more generic code, reducing duplication and significantly improving type safety across your codebase. The key takeaways are:
- Generic types act like functions that transform other types.
- Conditional types (using
extends ? :) enable branching logic within your type definitions. - The
inferkeyword functions like variable assignment, allowing you to extract and use parts of a type. - Recursion facilitates iteration over arrays and the definition of complex, nested types.
- Template literals empower string manipulation directly at the type level.
- Mapped types provide a mechanism for iterating and transforming object properties.
With these powerful tools, you can create sophisticated type utilities that adapt precisely to your needs, catch errors early at compile time, and enhance developer experience with excellent autocomplete in your IDE. The next time you write a type definition, remember that you are effectively writing a program.