Skip to content

Official guidelines to build complex types #63979

Description

🔍 Search Terms

Official guidelines

✅ Viability Checklist

⭐ Suggestion

Building complex type systems is quite hard with TS due to some limitations and pitfalls. We often find ourself fighting against TS while trying to circumvent some issues (a fight we often lose).

Currently, the official documentation describes TS functionalities, but not how to properly use it. Unfortunately, technically possible doesn't necessary mean recommended, supported, or something to do. For exemple some code base relies on h4ck that aren't supported by TS and might break (e.g. UnionToIntersection).

Correctly building a type system requires extensive knowledge on TS internals and limitations. Knowledge I mostly learned by reading (or opening) issues. Github issues are an enormous base of disparate knowledge.

Therefore, could it be possible to have some official guidelines to guide complex type definitions ? Giving us the officially supported tricks, tips, and patterns, and the most common pitfalls ?

I wrote an draft example below to illustrate what I am asking for.
Note that I wrote it quickly, without verifying everything, as this is only an exemple.

Complex type system

Use a common base

Whenever possible, identify a (simple) common base, and use it to define your complex types, e.g.:

type Cfg = Record<string, any>;
type WithCfg   <T extends Cfg> = {readonly cfg: T};
type CfgBuilder<T extends Cfg> = {...};

This helps type system uniformisation, simplifying its definition and usage.

Also, to help inferences, you can define types for inference to retrieve the common base from the complex types, e.g.:

type InferCfgFromWithCfg<T extends WithCfg<Cfg>> = ...;

Any and Unknown types

A same type can be use in several contexts to represent:

  • unknown value (the most restrictive).
  • any value (the most permissive, might be unsafe).

You can define types to represent this different cases:

type     AnyCfgBuilder = CfgBuilder<Cfg>;
type UnknownCfgBuilder = CfgBuilder<{}>;

Do NOT use any or unknown for that as they'll ignore bounded type constraints:

CfgBuilder<any>     // does not enforce "T extends Cfg"
CfgBuilder<unknwon> // error, unknown doesn't satisfies the constraint "T extends Cfg"

Defaults generic type parameters

You can also set defaults to the generic type parameters, but you might want to reserve it for when TS is unable to properly infer it:

// you give one type, but not a second:
type X<U, T = ...> = ... ;
type X1 = X<string>;

// for when the parameter is not provided.
function foo<T = ...>(arg?: T) {}
foo();

Exports default

Also, you might want to avoid the export default as it complexifies re-exports:

export {default as X} from "X";
export {X2} from "X"; // we can't put both in one export statement.

It also prevents value-type merging:

const X = ...;
type  X = ...;

export default X; // only exports the value.

Declaration merging

You can define a function with an interface, enabling declaration merging.

interface Foo {
	(): void;
}

interface Foo { ... }

You can also use extends to simplify some declaration merging:

interface Foo { ... };

interface Foo extends X<number> {};

Common helpers

Expand

type Expand<T> = T extends infer O
    ? { [K in keyof O]: O[K] }
    : never;

Type map

You can associate a type to another using the following structure:

interface ROMap {};
type X<RO, RW> = { (a: RO): RW }
type AsRW<RO> = ROMap extends (arg: RO) => infer U ? U : never;

// add an entry to the type map.
interface ROMap extends X<number, string> {}

// get :
type Y = AsRW<number>;

UnionToIntersection

UnionToIntersection isn't officially supported (might causes issue when using stableTypeOrdering).

Others

  • type branding: & Symbol and | Symbol.
  • extract ro keys
  • deep ro

Generic functions

Signature overload

You can define several signatures for a same function normally, but you can also use unions of arrays. You can also label elements:

function foo<T>(...args: [number, number]|[string]): void;
function foo<T>(...args: [a: number, b: number]): void;

Type inference inside generic function.

Inside a generic function, types are inferred during the function definition, not during its call. Meaning that TS might generalize type during type operation. Therefore, it is often better to explicitly define the return type in the signature, for it to be inferred during the call.

Choose the generic types

Usually, you want to write a signature as below, however this causes several issues:

function foo<T>(a: Arg<T>): Ret<T>;

Indeed, TS will raise an error if you call it with an union of Arg<T>.
To prevent it, let TS infer the parameter full type:

function foo<A extends Arg<T>>(a: A): Ret<InferTFromA<A>>;

If you wish to keep a simple usage, you can also do the following (be careful, T will sometime be inferred as unknown):

function foo<T, A extends Arg<T> = Arg<T>>(a: A): Ret<InferTFromA<A>>;

Non-inferred constraints

You can also add constraints to the argument without polluting the inferred type (in some contexts, it also helps inferring the argument type):

function foo<T>(a: T & Arg<any>): void;
// to exclude some keys:
function foo<T>(a: T & NoInfer<Partial<Record<..., never>>>): void;

Inference from the return type.

Sometimes, the generic type parameters are inferred from the function return type instead of the function parameters:

foo({
     faa: fct(arg)
});

However, TS might not know how to reverse some Ret types. Complex type mapping might prevents TS from reversing the type. For exemple:

type Ret<T> = {
	[K in keyof T as ...]: ...
}

You can then:

  • define the function signature to help inference: <R>(a: Arg<R>): R.
  • explicit the generic types: fct<number>(arg).
  • explicit the argument types: fct(arg: number).

It seems that you can't write a function signature that would be both parameters-inference and return-inference friendly. If you need both, you might want to write 2 distincts functions.

Generic classes

This

this isn't known during a class definition as the class can be inherited. this is therefore treated as a special generic type.

To store callbacks using this in their signatures, either:

  • bind them to the instance, so that the stored type doesn't depends on this.
  • force a type conversion to replace this by e.g. unknown when storing them.

Merge classes

You can manipulate and merge class types:

type Merge<A, B> = Omit<A, "prototype"> & Omit<B, "prototype"> // get static props.
		{
			// required, else:
			// Base constructors must all have the same return type.(2510)
			new(): InstanceType<A> & InstanceType<B>
			// protected properties aren't lost here.
		}

Explicit the class type

Using the class definition to infer some types has several issues:

  • you can't access static types from instance methods.
  • you can't access a generic method true type: this["method"]<T> doesn't work.
  • protected properties type are lost when manipulating types (often when using {[K in keyof T]}).
  • this might cause some type circularity issues.
  • if defined inside a function and returned, the type might have been generalized.

Often, you might want to define the type, and then force it to the class (this is even more true when generating the class inside a generic function):

type Instance = {};
type Cstr = { new(): Instance }

function foo(): Cstr {
     return class implements Instance{} satisfies Cstr;
}

const A = foo();
type A = InstanceType<typeof A>;

Attribute redefinition

Sometimes you might wish to redefine some attributes, that might break Liskov principle:

abstract class Base {
     abstract readonly props: string[];
     
     foo(): this["props"] {}
}

class A extends Base {
     readonly props: ["e"] as const;
}

class B extends A {
     readonly props: ["f"] as const;
}

You could use generic parameters on the class, however this can quickly become unpractical:

abstract class Base<T> {
     abstract readonly props: T;
     
     foo(): T {}
}

class A<T = ["e"]> extends Base<T> {}
class B<T = ["f"]> extends A   <T> {}

This might indicate a design issue. You can use a function to generate your classes:

const B = create({
     props: ["f"],
     // ... other options
});

// derive from existing properties, and override some.
const B = create({
     ... A.options
     props: ["f"],
});

// derive an existing class:
const B = derive(A, {...});

protected rw, public ro

Sometimes you want to define a property as read-only from the outside, and read-write from the inside. This forces you to define getter and setters:

class A {

     // ro
     protected _foo;
     get foo() { return this._foo }
     
     // ro type
     protected readonly _faa: RW;
     get foo(): RO { return this._faa };
}

However, you can also store it in its public shape, and use functions to access it:

class A {
     readonly foo;
     readonly faa: RO;
     
     fuu() {
         asMutable(this).foo = 2;
         
         // performs a protected operation:
         doX(this.faa)
         
         asRW(this.faa) // requires a type map.
     }
}

📃 Motivating Example

NA

💻 Use Cases

NA

Metadata

Metadata

Assignees

No one assigned

    Labels

    DocsThe issue relates to how you learn TypeScriptOut of ScopeThis idea sits outside of the TypeScript language design constraints

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions