Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[tools]
node = "22"
yarn = "1.22"
20 changes: 18 additions & 2 deletions packages/avro-ts-cli/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface Options {
outputDir?: string;
defaultsAsOptional?: boolean;
withTypescriptEnums?: boolean;
experimentalTypeOnlyNamespaces?: boolean;
}

export const convert = (logger: { log: (msg: string) => void } = console): commander.Command =>
Expand All @@ -23,7 +24,14 @@ export const convert = (logger: { log: (msg: string) => void } = console): comma
.arguments('[input...]')
.option('-O, --output-dir <outputDir>', 'Directory to write typescript files to')
.option('-e, --defaults-as-optional', 'Fields with defaults as optional')
.option('--with-typescript-enums', 'Flag to use Typescript Enums for Avro Enums instead of string union')
.option(
'--with-typescript-enums',
'Flag to use Typescript Enums for Avro Enums instead of string union',
)
.option(
'--experimental-type-only-namespaces',
'Emit type-only namespaces with runtime values in a sibling const object, compatible with TypeScript type stripping. Incompatible with --with-typescript-enums.',
)
.option(
'-l, --logical-type <logicalType>',
'Logical type, example: date=string',
Expand Down Expand Up @@ -76,6 +84,7 @@ Example:
avro-ts avro/*.avsc --output-dir other/dir
avro-ts avro/*.avsc --defaults-as-optional
avro-ts avro/*.avsc --with-typescript-enums
avro-ts avro/*.avsc --experimental-type-only-namespaces
avro-ts avro/*.avsc --logical-type date=string --logical-type datetime=string
avro-ts avro/*.avsc --logical-type-import decimal=Decimal:decimal.js
avro-ts avro/*.avsc --logical-type-import-default decimal=Decimal:decimal.js
Expand All @@ -93,6 +102,7 @@ Example:
outputDir,
defaultsAsOptional,
withTypescriptEnums,
experimentalTypeOnlyNamespaces,
}: Options,
) => {
if (files.length === 0) {
Expand Down Expand Up @@ -143,7 +153,13 @@ Example:
{},
);

const ts = toTypeScript(schema, { logicalTypes, external, defaultsAsOptional, withTypescriptEnums });
const ts = toTypeScript(schema, {
logicalTypes,
external,
defaultsAsOptional,
withTypescriptEnums,
experimentalTypeOnlyNamespaces,
});
const outputFile = outputDir ? join(outputDir, `${basename(file)}.ts`) : `${file}.ts`;
writeFileSync(outputFile, ts);
const shortFile = file.replace(process.cwd(), '.');
Expand Down
39 changes: 39 additions & 0 deletions packages/avro-ts/examples/temporal-logical-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { toTypeScript } from '@ovotech/avro-ts';
import { Schema } from 'avsc';

const avro: Schema = {
type: 'record',
name: 'Event',
fields: [
{ name: 'eventDate', type: { type: 'int', logicalType: 'date' } },
{ name: 'startTimeMillis', type: { type: 'int', logicalType: 'time-millis' } },
{ name: 'startTimeMicros', type: { type: 'long', logicalType: 'time-micros' } },
{ name: 'createdAt', type: { type: 'long', logicalType: 'timestamp-millis' } },
{ name: 'createdAtMicros', type: { type: 'long', logicalType: 'timestamp-micros' } },
{ name: 'localCreatedAt', type: { type: 'long', logicalType: 'local-timestamp-millis' } },
{ name: 'localCreatedAtMicros', type: { type: 'long', logicalType: 'local-timestamp-micros' } },
{
name: 'elapsed',
type: { type: 'fixed', name: 'Elapsed', size: 12, logicalType: 'duration' },
},
],
};

// Temporal is a global as of Node 26+, so every avro date/time logical type can be mapped losslessly onto its
// semantic Temporal counterpart
const ts = toTypeScript(avro, {
logicalTypes: {
date: 'Temporal.PlainDate',
'time-millis': 'Temporal.PlainTime',
'time-micros': 'Temporal.PlainTime',
'timestamp-millis': 'Temporal.Instant',
'timestamp-micros': 'Temporal.Instant',
// local-timestamp-* does not encode timezone, but also should not be assumed to be UTC (therefore not Instant)
// PlainDateTime is probably the closest equivalent
'local-timestamp-millis': 'Temporal.PlainDateTime',
'local-timestamp-micros': 'Temporal.PlainDateTime',
duration: 'Temporal.Duration',
},
});

console.log(ts);
2 changes: 1 addition & 1 deletion packages/avro-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"homepage": "https://github.com/ovotech/castle/tree/main/packages/avro-ts#readme",
"scripts": {
"test:js": "jest --runInBand",
"test:ts": "tsc test/integration.ts --strict --noEmit && ! tsc test/integration-should-fail.ts --strict --noEmit",
"test:ts": "tsc test/integration.ts --strict --noEmit && tsc test/integration-should-fail.ts --strict --noEmit",
"test": "yarn test:js && yarn test:ts",
"lint:prettier": "prettier --list-different {src,test}/**/*.ts",
"lint:eslint": "eslint '{src,test}/**/*.ts'",
Expand Down
22 changes: 19 additions & 3 deletions packages/avro-ts/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import { isEnumType, convertEnumType } from './types/enum';
import { isPrimitiveType, convertPrimitiveType } from './types/primitive';
import { isFixedType, convertFixedType } from './types/fixed';
import { withHeader, withImports } from '@ovotech/ts-compose/dist/document';
import { fullName, firstUpperCase, nameParts, convertName } from './helpers';
import {
fullName,
firstUpperCase,
nameParts,
convertName,
withSiblingObjects,
} from './helpers';
import * as ts from 'typescript';
import { convertNamedType, isNamedType } from './types/named-type';

Expand Down Expand Up @@ -91,13 +97,23 @@ export const convertType: Convert = (context, type) => {
};

export const toTypeScript = (schema: Schema, initial: Context = {}): string => {
if (initial.experimentalTypeOnlyNamespaces && initial.withTypescriptEnums) {
throw new Error(
'experimentalTypeOnlyNamespaces cannot be combined with withTypescriptEnums: TypeScript enums cannot be stripped from type-only namespaces.',
);
}

const contextWithRefs = collectRefs(schema, initial);
const { context, type } = convertType(contextWithRefs, schema);

const contextWithHeader = context.namespaces
? withHeader(context, '/* eslint-disable @typescript-eslint/no-namespace */')
const contextWithSiblings = context.experimentalTypeOnlyNamespaces
? withSiblingObjects(context)
: context;

const contextWithHeader = contextWithSiblings.namespaces
? withHeader(contextWithSiblings, '/* eslint-disable @typescript-eslint/no-namespace */')
: contextWithSiblings;

const name =
ts.isTypeReferenceNode(type) && ts.isQualifiedName(type.typeName)
? type.typeName.right
Expand Down
116 changes: 90 additions & 26 deletions packages/avro-ts/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,40 +24,104 @@ export const nameParts = (fullName: string): [string] | [string, string] => {
: [parts[0]];
};

export const namedType = (
// A single const, like `UserName = "com.example.User"`.
interface NamespaceConst {
name: string;
value: string;
}

// private symbol so that our internal accumlator it stays internal and doesn't pollute either public api
// or ts-compose internals
const siblingConsts = Symbol('siblingConsts');

type Namespace = string;

// The consts destined for each namespace's sibling object literal.
type SiblingConsts = Record<Namespace, NamespaceConst[]>;

type ContextWithSiblingConsts = Context & { [siblingConsts]?: SiblingConsts };

// collect consts for a namespace's sibling object literal, returning a new context.
const withSiblingConsts = (
context: ContextWithSiblingConsts,
namespaceName: Namespace,
consts: NamespaceConst[],
): ContextWithSiblingConsts => {
const collected = context[siblingConsts] ?? {};
const existing = collected[namespaceName] ?? [];

return {
...context,
[siblingConsts]: {
...collected,
[namespaceName]: [...existing, ...consts],
},
};
};

// Declare consts inside the namespace.
const withNamespaceConsts = (
context: Context,
namespaceName: Namespace,
consts: NamespaceConst[],
): Context => {
let result = context;
for (const { name, value } of consts) {
result = withIdentifier(result, Node.Const({ name, isExport: true, value }), namespaceName);
}
return result;
};

// Emit each namespace's collected consts as a sibling `const <Namespace> = { ... }` object literal.
export function withSiblingObjects(context: ContextWithSiblingConsts): Context {
let result: Context = context;

for (const [namespaceName, consts] of Object.entries(context[siblingConsts] ?? {})) {
const members: Record<string, string> = {};
for (const { name, value } of consts) {
members[name] = value;
}
result = withIdentifier(
result,
Node.Const({ name: namespaceName, isExport: true, multiline: true, value: members }),
);
}
return result;
}

export function namedType(
type: ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration,
context: Context,
schema: avroSchema.RecordType | avroSchema.EnumType,
namespace?: string,
): Document<ts.TypeNode, Context> => {
): Document<ts.TypeNode, Context> {
const name = convertName(firstUpperCase(schema.name));
const namespaceName = namespace ? convertName(namespace) : undefined;

const fullName = namespaceName ? [namespaceName, name] : name;
// No namespace: the type is emitted at the top level, with no schema/name consts.
if (!namespace || !namespaceName) {
return document(withIdentifier(context, type), Type.Referance(name));
}

const reference = Type.Referance([namespaceName, name]);
const fieldName = `${name}Name`;
const schemaName = `${namespace}.${fieldName}`;
const value = `${namespace}.${schema.name}`;
const schemaJson = JSON.stringify(schema);

const schemaValue = (name : string) => Node.Const({ name, isExport: true, value: JSON.stringify(schema) });

const contextWithRef = namespace
? /**
* If there is already a ref with the same name as our "named type", it means there is already
* a type with the same name and we're about to have a naming collision. To avoid this, we
* use the fully qualified name instead.
*/
context.refs && schemaName in context.refs
? withIdentifier(
withIdentifier(context, schemaValue(`${namespaceName}${name}Schema`), namespaceName),
Node.Const({ name: `${namespaceName}${fieldName}`, isExport: true, value }),
namespaceName,
)
: withIdentifier(
withIdentifier(context, schemaValue(`${name}Schema`), namespaceName),
Node.Const({ name: fieldName, isExport: true, value }),
namespaceName,
)
: context;

return document(withIdentifier(contextWithRef, type, namespaceName), Type.Referance(fullName));
};
// On a name collision with an existing ref, prefix the const names with the namespace.
const prefix = context.refs && schemaName in context.refs ? namespaceName : '';
const schemaConstName = `${prefix}${name}Schema`;
const nameConstName = `${prefix}${fieldName}`;

const consts: NamespaceConst[] = [
{ name: schemaConstName, value: schemaJson },
{ name: nameConstName, value },
];

const contextWithConsts = context.experimentalTypeOnlyNamespaces
? withSiblingConsts(context, namespaceName, consts)
: withNamespaceConsts(context, namespaceName, consts);

return document(withIdentifier(contextWithConsts, type, namespaceName), reference);
}
5 changes: 5 additions & 0 deletions packages/avro-ts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export interface Context extends DocumentContext {
external?: { [file: string]: { [key: string]: Schema } };
defaultsAsOptional?: boolean;
withTypescriptEnums?: boolean;
/**
* Emit type-only namespaces, moving runtime consts into a sibling object literal so the output is
* compatible with TypeScript type stripping. Cannot be combined with `withTypescriptEnums`.
*/
experimentalTypeOnlyNamespaces?: boolean;
}

export type Convert<TSchema = Schema, TType = ts.TypeNode> = (
Expand Down
Loading