When you order a custom physical product — packaging, furniture, a machined part — the whole transaction is secretly a data problem.
The buyer has a spec in their head. The manufacturer needs that spec in a form where nothing is ambiguous. Every field you didn't define turns into a phone call, a delay, or a remake.
I recently had to model a real product for a quoting workflow: a lacquered wooden wine box. Here's the schema I landed on, why each part exists, and a validator to keep bad specs out.
Why "just send a photo" fails
The default way custom orders happen is unstructured: an email thread, three photos, "about 30cm tall, glossy black", then a revised PDF later.
It fails for predictable reasons:
- Ambiguity — "30cm": external or internal? Measured with the lid closed?
- Drift — which version of the spec is current?
- Missing fields — nobody defines the hinge type until manufacturing starts.
- No validation — a spec can't be wrong if it isn't structured.
Structure fixes all four. Once the spec is data, it can be quoted, versioned, validated, and searched.
The schema
The core type, in TypeScript — the shape maps to any language:
type Dimensions = { widthMm: number; heightMm: number; depthMm: number };
type FinishKind =
| 'raw' | 'matte-lacquer' | 'gloss-lacquer' | 'printed-wrap';
type BrandingMethod =
| { kind: 'foil'; color: string }
| { kind: 'emboss' }
| { kind: 'metal-plaque'; plating: 'gold' | 'silver' | 'rose-gold' };
type CustomProductSpec = {
product: { name: string; category: string };
dimensions: { external: Dimensions; internal: Dimensions; toleranceMm: number };
structure: {
type: 'rigid' | 'folding' | 'clamshell' | 'drawer';
wallThicknessMm: number;
hingeType?: 'none' | 'metal-concealed' | 'metal-exposed';
};
materials: { coreMaterial: string; liningMaterial?: string };
finish: { primary: FinishKind };
branding: { methods: BrandingMethod[] };
quantity: { units: number; sampleRequested: boolean };
};
A few deliberate choices:
-
External vs internal dimensions are separate fields. Collapsing them into one
sizeis the single most common modeling mistake — they answer different questions (shipping vs fit). -
toleranceMmis required. "320mm" without a tolerance is not a specification. -
hingeTypeis conditional. It only means something for aclamshell. - Branding is a list. Real products mix methods — foil and a metal plaque.
A concrete spec
Filling it in for the wine box above (values are illustrative):
const wineBox: CustomProductSpec = {
product: { name: 'Glossy Black Wooden Wine Box', category: 'spirits-packaging' },
dimensions: {
external: { widthMm: 320, heightMm: 240, depthMm: 120 },
internal: { widthMm: 300, heightMm: 200, depthMm: 100 },
toleranceMm: 1.5,
},
structure: { type: 'clamshell', wallThicknessMm: 12, hingeType: 'metal-exposed' },
materials: { coreMaterial: 'MDF', liningMaterial: 'velvet' },
finish: { primary: 'gloss-lacquer' },
branding: { methods: [{ kind: 'metal-plaque', plating: 'gold' }] },
quantity: { units: 500, sampleRequested: true },
};
Notice what the structured form forces you to decide before production: the lining material, the plating colour, whether a sample is requested. In an email thread, three of those would surface as surprises later.
A validator
Structure without validation is just a nicer-looking mess. The rules that matter:
function validateSpec(s: CustomProductSpec): string[] {
const errors: string[] = [];
const { external, internal, toleranceMm } = s.dimensions;
// Internal must fit inside external.
if (internal.widthMm >= external.widthMm)
errors.push('internal width must be smaller than external width');
if (internal.heightMm >= external.heightMm)
errors.push('internal height must be smaller than external height');
// A tolerance of zero is physically impossible.
if (toleranceMm <= 0)
errors.push('toleranceMm must be greater than 0');
// Wall must be positive and structurally sane.
if (s.structure.wallThicknessMm <= 0)
errors.push('wallThicknessMm must be greater than 0');
// A clamshell without a hinge is an incomplete spec.
if (s.structure.type === 'clamshell' && !s.structure.hingeType)
errors.push('clamshell requires a hingeType');
if (s.quantity.units < 1)
errors.push('quantity must be at least 1');
return errors;
}
Run validateSpec(wineBox) and you get []. Remove the hingeType and it tells you exactly what's missing — before you order, not after.
Why this generalizes
The same skeleton — dimensions + structure + materials + finish + branding + quantity — describes almost any made-to-order physical product. A garment adds sizeChart; a machined part adds materialGrade and surfaceFinish. The bones don't change.
Once physical specs are data, you get for free:
- Instant quoting — a pricing function consumes the spec.
- Versioning — specs diff like code.
- Validation — catch impossible combinations before tooling.
- Search — "every clamshell box we've made with velvet lining."
Takeaways
- A custom order is a data problem wearing a supply-chain costume.
- Split external and internal dimensions; add a tolerance.
- Make conditional fields conditional, and validate what you can.
If you've modeled a physical product's spec before, I'd be curious how your schema differs — the domain-specific fields are usually the interesting part.
The field set above was shaped by modeling real custom orders from TANCYBOX, a packaging manufacturer — the fields buyers most often forget are the ones that cause rework.


Top comments (0)