@marianmeres/dtokit@1.0.6
A generic, type-safe factory for working with discriminated unions in TypeScript.
@marianmeres/dtokit
A generic, type-safe factory for working with discriminated unions in TypeScript.
Zero dependencies. Full type safety. Minimal runtime overhead.
Thanks to Lukas Votypka for the initial idea.
The Problem
When receiving data through transports like WebSockets, postMessage, or custom protocols, you often need to:
- Identify what type of message you received
- Get full TypeScript type safety for that specific message type
- Do this without runtime validation overhead
If your API uses a discriminator pattern (a field like id or type with literal string values), this factory automates the type narrowing.
Installation
Deno (JSR)
deno add jsr:@marianmeres/dtokit
Node.js (npm)
npm install @marianmeres/dtokit
Quick Start
import { createDtoFactory } from '@marianmeres/dtokit'; // Your schema: an object where each property is a type with a literal discriminator field interface Schemas { FooMessage: { id: 'foo' }; BarMessage: { id: 'bar'; data?: { text: string } }; } // 1. Create a factory for your schemas, specifying the discriminator field const messages = createDtoFactory<Schemas>()('id'); // 2. Parse incoming data const dto = messages.parse(rawWebSocketMessage); // 3. Use type guards for full type safety if (dto && messages.is(dto, 'bar')) { // dto is fully typed as BarMessage console.log(dto.data?.text); }
Why Not Class Instances?
You might wonder: "Why does parse() return plain objects? Couldn't there be a factory() method that returns actual class instances with methods?"
Technically yes, but it would defeat the purpose of this library.
The core value of dtokit is zero boilerplate:
schema types → immediate type-safe usage
To support class instances, you'd need to:
- Manually write a class for each message type
- Register each class constructor with the factory
- Keep classes in sync with schema changes
At that point, you're just writing:
if (raw.id === 'bar') return new BarMessage(raw);
...which requires no library at all. The "factory" would be syntactic sugar over a switch statement you have to maintain anyway.
Bottom line: If you need class instances with methods, you're solving a different problem than what dtokit addresses. This library is for when your types are sufficient and you just need type-safe runtime narrowing.
How It Works
The Discriminator Pattern
Your schemas must be an object type where each property represents a message type with a literal string value for the discriminator field:
interface Schemas { FooMessage: { id: 'foo' }; // literal "foo" BarMessage: { id: 'bar'; data: { text: string } }; // literal "bar" }
The factory uses TypeScript's type system to:
- Find all types with a literal value for your discriminator field
- Build a map from discriminator values to their types
- Provide type guards that narrow to the exact type
// The factory automatically discovers: // { "foo": FooMessage, "bar": BarMessage, ... }
Real-World Example: OpenAPI
A common source of such schemas is openapi-typescript, which generates TypeScript types from OpenAPI specs:
# OpenAPI schema components: schemas: FooMessage: properties: id: type: string enum: ["foo"] # becomes literal type!
// Generated TypeScript import type { components } from './generated-types'; const messages = createDtoFactory<components['schemas']>()('id');
API Reference
For complete API documentation, see API.md.
| Method | Description |
|---|---|
createDtoFactory<Schemas>()(field) |
Creates a factory for your schemas with the given discriminator field |
factory.parse(raw) |
Parses unknown data into a typed DTO (returns null if invalid) |
factory.is(dto, id) |
Type guard to narrow a DTO to a specific type |
factory.getId(dto) |
Gets the discriminator value from a DTO |
factory.isValid(raw) |
Checks if raw data has a valid discriminator field |
factory.field |
The discriminator field name (readonly) |
Advanced Usage
Switch-Style Exhaustive Handling
Use createDtoHandler for exhaustive switch-like handling:
import { createDtoHandler } from '@marianmeres/dtokit'; const handleMessage = createDtoHandler<Schemas>()('id', { foo: (dto) => console.log('Foo received'), bar: (dto) => console.log('Bar:', dto.data?.text), // TypeScript ERROR if you miss any message type! }); handleMessage(dto);
Exporting Derived Types
Export types for use in other modules:
import { createDtoFactory, DiscriminatorMap, DiscriminatorId, AnyDto } from '@marianmeres/dtokit'; export const messages = createDtoFactory<Schemas>()('id'); export type MessageMap = DiscriminatorMap<Schemas, 'id'>; export type MessageId = DiscriminatorId<Schemas, 'id'>; export type AnyMessage = AnyDto<Schemas, 'id'>;
Changing the Discriminator Field
If you later decide to use type instead of id:
// Before const messages = createDtoFactory<Schemas>()('id'); // After - just change one string! const messages = createDtoFactory<Schemas>()('type');
All your existing code continues to work.
Multiple Discriminator Patterns
If your API has different discriminator fields for different schema groups:
// WebSocket messages use 'id' const wsMessages = createDtoFactory<WsSchemas>()('id'); // REST responses use 'type' const apiResponses = createDtoFactory<ApiSchemas>()('type');
Real World Integration Example
The @marianmeres/actor library provides typed actors with compile-time exhaustive message handling powered by dtokit:
import { createTypedStateActor } from '@marianmeres/actor'; type Schemas = { INC: { type: 'INC' }; DEC: { type: 'DEC' }; ADD: { type: 'ADD'; amount: number }; }; const counter = createTypedStateActor<Schemas, number>(0, { INC: (_, state) => state + 1, DEC: (_, state) => state - 1, // ADD: (msg, state) => state + msg.amount, // intentionally omitted // TypeScript will error as we missed the ADD message type });
Type Utilities Reference
These types are exported for advanced use cases:
| Type | Description |
|---|---|
DiscriminatorMap<S, F> |
Map from discriminator values to types |
DiscriminatorId<S, F> |
Union of all discriminator values |
AnyDto<S, F> |
Union of all DTO types |
HasLiteralField<T, F> |
Filter for types with literal field |
ExtractFieldValue<T, F> |
Extract literal value from type |
DtoHandlers<S, F, R> |
Handler map type for exhaustive handling |
For detailed documentation of each type, see API.md.
FAQ
Why not use XYZ library for runtime validation?
This factory is intentionally lightweight. It only checks that the discriminator field exists and is a string—it trusts the shape beyond that. This is appropriate when:
- Your data comes from a trusted source (your own backend)
- You want minimal runtime overhead
- Your schema types are your source of truth
If you need full runtime validation, consider using Zod or similar libraries.
Why the double function call createDtoFactory<S>()(field)?
This is a workaround for a TypeScript limitation: you cannot partially specify generic type parameters. It's either all or nothing.
The Problem
In an ideal world, you'd write:
// HYPOTHETICAL - doesn't work in TypeScript const messages = createDtoFactory<Schemas>('id'); // ↑ explicit ↑ inferred
You want to:
- Explicitly specify
Schemas(because it can't be inferred from arguments) - Let TypeScript infer
Fieldfrom the string'id'
But TypeScript requires you to specify all type parameters or none.
Without Currying (Doesn't Work Well)
If the function had a single call signature:
function createDtoFactory<Schemas, Field extends string>( discriminatorField: Field ): DtoFactory<Schemas, Field>
Your options would be:
// Option 1: Specify only Schemas createDtoFactory<Schemas>('id'); // ERROR: Expected 2 type arguments, but got 1 // Option 2: Specify both - works but verbose and error-prone createDtoFactory<Schemas, 'id'>('id'); // ↑ redundant! must match the string exactly // Option 3: Specify neither createDtoFactory('id'); // Schemas becomes `unknown` - useless, no type safety
The Curried Solution
By splitting into two function calls, each function gets its own generic scope:
function createDtoFactory<Schemas>() { return function <Field extends string>( discriminatorField: Field ): DtoFactory<Schemas, Field>; }
Now TypeScript can handle each step independently:
const messages = createDtoFactory<Schemas>()('id'); // ↑ first call: you specify Schemas explicitly // ↑ second call: Field inferred as literal "id"
Is This Pattern Common?
Yes! This curried pattern is used by many TypeScript libraries facing the same limitation, including Zod, tRPC, and others. It's the standard workaround until TypeScript adds partial type argument inference (a long-requested feature)
What if a schema has both id and type fields?
The factory only looks at the field you specify. A schema will be included if it has a literal value for that specific field.
License
MIT
Add Package
deno add jsr:@marianmeres/dtokit
Import symbol
import * as dtokit from "@marianmeres/dtokit";
Import directly with a jsr specifier
import * as dtokit from "jsr:@marianmeres/dtokit";
Add Package
pnpm i jsr:@marianmeres/dtokit
pnpm dlx jsr add @marianmeres/dtokit
Import symbol
import * as dtokit from "@marianmeres/dtokit";
Add Package
yarn add jsr:@marianmeres/dtokit
yarn dlx jsr add @marianmeres/dtokit
Import symbol
import * as dtokit from "@marianmeres/dtokit";
Add Package
vlt install jsr:@marianmeres/dtokit
Import symbol
import * as dtokit from "@marianmeres/dtokit";
Add Package
npx jsr add @marianmeres/dtokit
Import symbol
import * as dtokit from "@marianmeres/dtokit";
Add Package
bunx jsr add @marianmeres/dtokit
Import symbol
import * as dtokit from "@marianmeres/dtokit";