Skip to content

Repeating a pattern within a project

You have a React/TypeScript app where every feature module looks the same: index.ts, store.ts, api.ts, types.ts. When you need a new feature, you copy products/ and start renaming. You get Order, OrderState, fetchOrders — but the import in api.ts still reads import type { Product } from './types'. TypeScript catches that one. The fetch('/api/products') URL in the same file does not cause a type error. It silently hits the wrong endpoint until someone notices orders returning product data.

Extract the pattern into a template that lives inside the project. Adding the next feature module is one command.

Your src/features/ directory has two modules already:

src/features/
users/
index.ts
store.ts
api.ts
types.ts
products/
index.ts
store.ts
api.ts
types.ts

Each module follows the same conventions: types are exported from types.ts, the API layer imports from there, and index.ts re-exports everything. You want orders/ to look exactly the same.

Here is what orders/api.ts looks like after a hurried copy from products/:

// orders/api.ts — after copying from products/
import type { Product } from './types'; // wrong — should be Order
export async function fetchOrders(): Promise<Product[]> {
const response = await fetch('/api/products'); // wrong URL — silent bug
return response.json();
}

This compiles. TypeScript sees no error because Product[] is a valid return type. The fetch URL is wrong and nothing will tell you until an order view renders product data.

Add a _template/ directory alongside the existing modules. The underscore signals to your team that this is not a real feature.

src/features/
_template/
diecut.toml
template/
users/
...
products/
...

Everything under _template/template/ becomes the generated module. Files ending in .die are rendered through the Tera template engine and have the suffix stripped. Everything else is copied as-is.

Create src/features/_template/diecut.toml:

[template]
name = "feature-module"
[variables.feature_name]
type = "string"
prompt = "Feature name"
default = "my-feature"
validation = '^[a-z][a-z0-9-]*$'
validation_message = "Must start with a letter. Only lowercase letters, numbers, hyphens."
[variables.FeatureName]
type = "string"
computed = "{{ feature_name | replace(from='-', to=' ') | title | replace(from=' ', to='') }}"

feature_name is the kebab-case slug the user types — orders, shopping-cart, line-items. The validation rejects anything that would produce a broken import path.

FeatureName is computed from it. Tera’s title filter capitalises each word, then the two replace calls strip hyphens and spaces, giving you Orders, ShoppingCart, LineItems. This is what you use wherever TypeScript expects a type name or interface prefix. Computed variables are never prompted — diecut derives them automatically.

Create src/features/_template/template/types.ts.die:

export interface {{ FeatureName }} {
id: string;
}
export interface {{ FeatureName }}State {
items: {{ FeatureName }}[];
loading: boolean;
error: string | null;
}

Create src/features/_template/template/api.ts.die:

import type { {{ FeatureName }} } from './types';
export async function fetch{{ FeatureName }}s(): Promise<{{ FeatureName }}[]> {
const response = await fetch('/api/{{ feature_name }}');
return response.json();
}
export async function fetch{{ FeatureName }}(id: string): Promise<{{ FeatureName }}> {
const response = await fetch(`/api/{{ feature_name }}/${id}`);
return response.json();
}

Create src/features/_template/template/index.ts.die:

export type { {{ FeatureName }}, {{ FeatureName }}State } from './types';
export { fetch{{ FeatureName }}s, fetch{{ FeatureName }} } from './api';

Your template directory now looks like this:

src/features/_template/
diecut.toml
template/
types.ts.die
api.ts.die
index.ts.die

Run with --dry-run --verbose to see what diecut would generate without touching the filesystem:

Terminal window
diecut new ./src/features/_template -o src/features/orders --dry-run --verbose
Feature name [my-feature]: orders
[dry-run] would write: src/features/orders/types.ts
[dry-run] would write: src/features/orders/api.ts
[dry-run] would write: src/features/orders/index.ts

Check the filenames. If they look right, generate for real:

Terminal window
diecut new ./src/features/_template -o src/features/orders
src/features/orders/
types.ts
api.ts
index.ts
.diecut-answers.toml

The generated types.ts:

export interface Order {
id: string;
}
export interface OrderState {
items: Order[];
loading: boolean;
error: string | null;
}

The generated api.ts:

import type { Order } from './types';
export async function fetchOrders(): Promise<Order[]> {
const response = await fetch('/api/orders');
return response.json();
}
export async function fetchOrder(id: string): Promise<Order> {
const response = await fetch(`/api/orders/${id}`);
return response.json();
}

The import type { Order } on line 1 and fetch('/api/orders') on line 5 both derive from the single value orders typed at the prompt. There is no second place to update.

The template pays off most when the pattern changes. When store.ts becomes standard, you add store.ts.die to _template/ once. Every module created after that gets it. Without the template, you update users/, products/, and orders/, but someone adding notifications/ next quarter copies an older module and ships without a store.

users/ and products/ already existed when this template was written. Extracting a template meant naming the parts that vary and writing down what was already there — not designing something new from scratch.

The template lives in the project directory, next to the modules it produces. Teammates find it where they’d look.


To learn more about computed variables, filters, and other template features, see Creating Templates. For all CLI options, see the Commands reference.