Type-safety doesn’t just help engineers build less buggy software, it’s a tool that can be leveraged to help engineers feel confident that their changes and additions are implemented correctly and in full.
I want the build to fail when I’ve missed something or haven’t implemented something in a way that it should have been - across an entire codebase. Alarm bells ring for me when I see code that won’t scale as it grows, where the language tools we have can help to negate these issues.
TypeScript has enabled the use of types to describe our data models, function signatures, and component prop types. Whilst these uses solve a relatively simple (but valuable) set of issues, you can unlock further improvements to developer experience and reduced bug rates by changing the way you write …
Type-safety doesn’t just help engineers build less buggy software, it’s a tool that can be leveraged to help engineers feel confident that their changes and additions are implemented correctly and in full.
I want the build to fail when I’ve missed something or haven’t implemented something in a way that it should have been - across an entire codebase. Alarm bells ring for me when I see code that won’t scale as it grows, where the language tools we have can help to negate these issues.
TypeScript has enabled the use of types to describe our data models, function signatures, and component prop types. Whilst these uses solve a relatively simple (but valuable) set of issues, you can unlock further improvements to developer experience and reduced bug rates by changing the way you write your code to enable further type-safety. For those new to TypeScript, thinking this way can help improve your experience writing TypeScript and supporting the applications you create.
The Manifest Pattern is an example of this, and I’ve used it for a long time to solve a lot of dynamic interface problems. I’ve used it to build dynamic dashboarding and widget display and configuration systems, complex logic builders, and much more.
The pattern enables engineers to build type-safe dynamic user interfaces that encourages the co-location of code to improve developer experience, and pairs extremely well with a discriminated union. Our logic and views can remain simple and terse, and engineers don’t need to go on a quest to add support for something new.
The Manifest Pattern isn’t anything complex technically. It’s a fancy version of dependency injection and interfacing, but the dependency can and will change implementation almost every time it’s used.
The Manifest Pattern is more about what it means and enforcing how you use it: describe every requirement and implementation-specific detail within the manifests and never outside. This includes values, components, functions, and side effects. If you’re adding code outside of a manifest implementation to do something specific to one thing, you’re using the pattern incorrectly.
The manifests should define how an implementation operates within your application. If your application needs to behave differently based on a dynamic option, define it within the manifest implementations.
Though you don’t need to depend on these tools to use the pattern, I’ve created the manifest-pattern package on npm that provides a nice API when using the Manifest Pattern. You can find the code on GitHub too.
Getting Started with the Manifest Pattern
Creating your Manifest Type
The first step is to define your Manifest type around your own data types, such as a union or discriminated union. Your Manifest interface should encapsulate all the functionality your application needs, including defining values, functions, and UI components for each unique implementation.
You can extend your Manifest interface over time and implement the changes across every Manifest implementation to ensure your application will function correctly, and your build is successful.
In this example, let’s say we have an interface that allows users to connect your application to different storage providers in a generic way.
import type { Manifest } from 'manifest-pattern';
type PostgresConnector = {
type: 'pg';
host: string;
database: string;
// ...username, password, ...
};
type RedisConnector = {
type: 'redis';
host: string;
port: number;
}
type Connector = PostgresConnector | RedisConnector;
interface ConnectorManifest<T extends Connector = Connector> extends Manifest<T['type']> {
listName: string;
/**
* Decide whether the option should be visible in the user interface or not
* @returns boolean
*/
shouldAllowSelection(): boolean;
/**
* Decide whether an instance of the Connector is a valid object
* @param {Partial<T>} connector
* @returns boolean;
*/
isValid(connector: Partial<T>): boolean;
formConfigurationComponent: React.ComponentType<ViewComponentProps> | null;
}
Your Manifest type will change and can adapt as your application adds functionality. The ConnectorManifest interface could be extended with a getDefaultConnector(): Partial<T> method that could be used to get some initial form values for configuring the connector.
Create your Manifest Implementations
Implementing a Manifest for each Connector type will allow us to define the specifics of how each connector should operate within our application, including what components to render and whether it should be allowed to be selected by a user.
The Manifest classes (or objects) can be tested as units, and as part of a wider system.
class PostgresConnectorManifest implements ConnectorManifest<PostgresConnector> {
public type: PostgresConnector['type'] = 'pg';
public listName = 'Postgres Database';
/**
* @inheritdoc
*/
public shouldAllowSelection() {
return true;
}
/**
* @inheritdoc
*/
public isValid(connector: Partial<PostgresConnector>): boolean {
// Define custom validation logic specifically for PostgresConnector
return true;
}
public formConfigurationComponent = null;
}
Co-locate your manifest implementations to give engineers one consistent place to look and a pattern to follow:
Connectors/
├── PostgresConnector/
│ ├── PostgresConnector.ts
│ ├── PostgresConnectorManifest.ts
│ └── PostgresConnectorConfigurationForm.tsx
├── RedisConnector/
│ ├── RedisConnector.ts
│ ├── RedisConnectorManifest.ts
│ └── RedisConnectorConfigurationForm.tsx
├── Connector.ts
├── ConnectorManifest.ts
└── ConnectorManifestRegistry.ts
Create your Manifest Registry
Create a Manifest Registry to get a generic API that allows engineers to access the manifests. This can be done in one of two ways when using the manifest-pattern package.
import { ManifestRegistry, createManifestRegistry } from 'manifest-pattern';
// Option 1: Add when needed or ready
const connectorManifestRegistry = new ManifestRegistry<ConnectorManifest>()
.addManifest(new PostgresConnectorManifest())
.addManifest(new RedisConnectorManifest());
// Option 2: Add all upfront
const connectorManifestRegistry = createManifestRegistry<ConnectorManifest>({
pg: new PostgresConnectorManifest(),
redis: new RedisConnectorManifest()
});
Depending on load order or your constructors, you may want to store the instance of the registry in memory so only one registry instance is created.
Use the Manifest Registry
The registry provides a generic API to access your manifest instances. Whether you need all of the instances to power a list of options in the interface, or need a specific instance of a given type to validate a form, you do it through the registry’s three main get methods.
getManifests(): Manifest[]
Get all Manifest instances added to the registry. Useful when providing users with the ability to select an option from a list, rendering dynamic lists of content, and more.
type SelectOption = {
id: Connector['type'];
label: string;
};
const selectOptions = connectorManifestRegistry
.getManifests()
.filter(m => m.shouldAllowSelection())
.map((m): SelectOption => ({
id: m.type,
label: m.listName
}));
getManifestOrNull(type: T): Manifest | null
Get a specific instance of a manifest by type, or return null. This method is the most-used and allows code to remain generic to the specific details of a single implementation.
type Props = {
connectorType: Connector['type'];
};
const FormRenderer = (props: Props): React.ReactNode | null => {
const [formError, setFormError] = React.useState<string | null>(null);
const [formValues, setFormValues] = React.useState<Partial<Connector>>({
type: props.connectorType
});
const manifest = React.useMemo(
() => connectorManifestRegistry.getManifestOrNull(props.connectorType),
[props.connectorType]
);
// Retrieve the React component to render dynamically
const FormConfigurationComponent = manifest?.formConfigurationComponent ?? null;
if (manifest === null || FormConfigurationComponent === null) {
return null;
}
const handleFormSubmit = (formValues: Partial<Connector>): void => {
setFormValues(formValues);
// Dynamically call a method on the Manifest to validate the values
const isValid = manifest.isValid(formValues);
if (!isValid) {
setFormError('Invalid Connector');
return;
}
// TODO: Make a request, navigate away...
};
return (
<div>
<FormConfigurationComponent
error={formError}
values={formValues}
onSubmit={handleFormSubmit}
/>
</div>
);
};
getManifestOrThrow(type: T): Manifest
Similar to getManifestOrNull, but instead throws an Error when a manifest is not found for the type given.
const formValidator = (formValues): string | null => {
try {
const manifest = connectorManifestRegistry.getManifestOrNull(formValues.selectedValueType);
return !manifest.isValid(formValues.value) ? 'Values entered are invalid' : null;
} catch (err) {
return 'Invalid Connector type selected';
}
};
If you’re about to build a dynamic interface or application, give the Manifest Pattern a try. You can find the code on GitHub, the package on npm, and me on Mastodon or Bluesky.