protobuf codec with static type inference jsr.io/@mary/protobuf
typescript jsr
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: oneof support

Mary b28c5f4b f55e7ecc

+444 -23
+32 -1
README.md
··· 72 72 next: 2, 73 73 }); 74 74 } 75 + 76 + // oneof fields 77 + { 78 + const TextContent = p.message({ 79 + body: p.string(), 80 + }, { body: 1 }); 81 + 82 + const ImageContent = p.message({ 83 + url: p.string(), 84 + width: p.int32(), 85 + }, { url: 1, width: 2 }); 86 + 87 + const Post = p.message({ 88 + title: p.string(), 89 + content: p.oneof({ 90 + text: TextContent, 91 + image: ImageContent, 92 + }), 93 + }, { 94 + title: 1, 95 + content: { text: 2, image: 3 }, 96 + }); 97 + 98 + const post: p.InferInput<typeof Post> = { 99 + title: 'Hello', 100 + content: { case: 'text', value: { body: 'world' } }, 101 + }; 102 + 103 + const encoded = p.encode(Post, post); 104 + const decoded = p.decode(Post, encoded); 105 + // ^? { title: string, content?: { case: 'text', value: ... } | { case: 'image', value: ... } } 106 + } 75 107 ``` 76 108 77 109 ## non-features 78 110 79 111 - **enums support**: use `int32()` instead for open enums 80 - - **oneof support**: complicated, breaks self-referential messages 81 112 - **extensions support**: not supported 82 113 - **groups support**: use nested messages instead 83 114 - **code generation**: no intent
+247
lib/mod.test.ts
··· 3 3 4 4 import * as p from './mod.ts'; 5 5 6 + function assertType<T>(_value: T): void {} 7 + 6 8 // #region Primitive types 7 9 8 10 Deno.test('string encoding/decoding', () => { ··· 1465 1467 }); 1466 1468 1467 1469 // #endregion 1470 + 1471 + // #region Oneof 1472 + 1473 + Deno.test('oneof encoding/decoding', () => { 1474 + const Message = p.message({ 1475 + id: p.int32(), 1476 + result: p.oneof({ 1477 + name: p.string(), 1478 + count: p.int32(), 1479 + }), 1480 + }, { 1481 + id: 1, 1482 + result: { name: 2, count: 3 }, 1483 + }); 1484 + 1485 + // string variant 1486 + { 1487 + const encoded = p.encode(Message, { id: 1, result: { case: 'name', value: 'hello' } }); 1488 + const decoded = p.decode(Message, encoded); 1489 + assertEquals(decoded, { id: 1, result: { case: 'name', value: 'hello' } }); 1490 + } 1491 + 1492 + // int32 variant 1493 + { 1494 + const encoded = p.encode(Message, { id: 2, result: { case: 'count', value: 42 } }); 1495 + const decoded = p.decode(Message, encoded); 1496 + assertEquals(decoded, { id: 2, result: { case: 'count', value: 42 } }); 1497 + } 1498 + 1499 + // no variant set 1500 + { 1501 + const encoded = p.encode(Message, { id: 3 }); 1502 + const decoded = p.decode(Message, encoded); 1503 + assertEquals(decoded, { id: 3 }); 1504 + } 1505 + }); 1506 + 1507 + Deno.test('oneof with nested messages', () => { 1508 + const TextContent = p.message({ 1509 + body: p.string(), 1510 + }, { body: 1 }); 1511 + 1512 + const ImageContent = p.message({ 1513 + url: p.string(), 1514 + width: p.int32(), 1515 + }, { url: 1, width: 2 }); 1516 + 1517 + const Post = p.message({ 1518 + title: p.string(), 1519 + content: p.oneof({ 1520 + text: TextContent, 1521 + image: ImageContent, 1522 + }), 1523 + }, { 1524 + title: 1, 1525 + content: { text: 2, image: 3 }, 1526 + }); 1527 + 1528 + // text variant 1529 + { 1530 + const data = { title: 'Hello', content: { case: 'text' as const, value: { body: 'world' } } }; 1531 + const encoded = p.encode(Post, data); 1532 + const decoded = p.decode(Post, encoded); 1533 + assertEquals(decoded, data); 1534 + } 1535 + 1536 + // image variant 1537 + { 1538 + const data = { title: 'Photo', content: { case: 'image' as const, value: { url: 'https://example.com/img.png', width: 800 } } }; 1539 + const encoded = p.encode(Post, data); 1540 + const decoded = p.decode(Post, encoded); 1541 + assertEquals(decoded, data); 1542 + } 1543 + }); 1544 + 1545 + Deno.test('oneof last-one-wins on decode', () => { 1546 + // Manually construct wire data with two oneof variants set. 1547 + // The decoder should keep the last one. 1548 + const Message = p.message({ 1549 + result: p.oneof({ 1550 + name: p.string(), 1551 + count: p.int32(), 1552 + }), 1553 + }, { 1554 + result: { name: 1, count: 2 }, 1555 + }); 1556 + 1557 + // Encode two separate messages and concatenate — protobuf merges on decode 1558 + const encoded1 = p.encode(Message, { result: { case: 'name', value: 'first' } }); 1559 + const encoded2 = p.encode(Message, { result: { case: 'count', value: 99 } }); 1560 + 1561 + const combined = new Uint8Array(encoded1.length + encoded2.length); 1562 + combined.set(encoded1); 1563 + combined.set(encoded2, encoded1.length); 1564 + 1565 + // ~~decode works on raw bytes (no length prefix), so use it directly 1566 + const decoded = p.decode(Message, combined); 1567 + assertEquals(decoded, { result: { case: 'count', value: 99 } }); 1568 + }); 1569 + 1570 + Deno.test('oneof with various wire types', () => { 1571 + const Message = p.message({ 1572 + value: p.oneof({ 1573 + text: p.string(), 1574 + flag: p.boolean(), 1575 + big: p.int64(), 1576 + precise: p.double(), 1577 + raw: p.bytes(), 1578 + }), 1579 + }, { 1580 + value: { text: 1, flag: 2, big: 3, precise: 4, raw: 5 }, 1581 + }); 1582 + 1583 + const roundtrip = (input: p.InferInput<typeof Message>) => { 1584 + const encoded = p.encode(Message, input); 1585 + const decoded = p.decode(Message, encoded); 1586 + assertEquals(decoded, input); 1587 + }; 1588 + 1589 + roundtrip({ value: { case: 'text', value: 'hello' } }); 1590 + roundtrip({ value: { case: 'flag', value: true } }); 1591 + roundtrip({ value: { case: 'big', value: 42n } }); 1592 + roundtrip({ value: { case: 'precise', value: 3.14 } }); 1593 + roundtrip({ value: { case: 'raw', value: new Uint8Array([1, 2, 3]) } }); 1594 + }); 1595 + 1596 + Deno.test('oneof alongside regular and optional fields', () => { 1597 + const Message = p.message({ 1598 + id: p.int32(), 1599 + label: p.optional(p.string()), 1600 + kind: p.oneof({ 1601 + name: p.string(), 1602 + age: p.int32(), 1603 + }), 1604 + tags: p.repeated(p.string()), 1605 + }, { 1606 + id: 1, 1607 + label: 2, 1608 + kind: { name: 3, age: 4 }, 1609 + tags: 5, 1610 + }); 1611 + 1612 + // all fields present 1613 + { 1614 + const data = { 1615 + id: 10, 1616 + label: 'test', 1617 + kind: { case: 'age' as const, value: 25 }, 1618 + tags: ['a', 'b'], 1619 + }; 1620 + const encoded = p.encode(Message, data); 1621 + const decoded = p.decode(Message, encoded); 1622 + assertEquals(decoded, data); 1623 + } 1624 + 1625 + // optional and oneof absent 1626 + { 1627 + const data = { id: 5, tags: ['x'] }; 1628 + const encoded = p.encode(Message, data); 1629 + const decoded = p.decode(Message, encoded); 1630 + assertEquals(decoded, { id: 5, tags: ['x'] }); 1631 + } 1632 + }); 1633 + 1634 + Deno.test('oneof type validation during encoding', () => { 1635 + const Message = p.message({ 1636 + result: p.oneof({ 1637 + name: p.string(), 1638 + }), 1639 + }, { 1640 + result: { name: 1 }, 1641 + }); 1642 + 1643 + // non-object value for oneof should error 1644 + // deno-lint-ignore no-explicit-any 1645 + const result = p.tryEncode(Message, { result: 'not an object' } as any); 1646 + assert(!result.ok); 1647 + }); 1648 + 1649 + Deno.test('oneof unknown case is silently skipped', () => { 1650 + const Message = p.message({ 1651 + id: p.int32(), 1652 + result: p.oneof({ 1653 + name: p.string(), 1654 + }), 1655 + }, { 1656 + id: 1, 1657 + result: { name: 2 }, 1658 + }); 1659 + 1660 + // Unknown case should not encode anything for the oneof 1661 + // deno-lint-ignore no-explicit-any 1662 + const encoded = p.encode(Message, { id: 1, result: { case: 'nonexistent', value: 'x' } } as any); 1663 + const decoded = p.decode(Message, encoded); 1664 + assertEquals(decoded, { id: 1 }); 1665 + }); 1666 + 1667 + Deno.test('oneof type inference', () => { 1668 + const Inner = p.message({ value: p.int32() }, { value: 1 }); 1669 + 1670 + const Message = p.message({ 1671 + id: p.int32(), 1672 + label: p.optional(p.string()), 1673 + result: p.oneof({ 1674 + name: p.string(), 1675 + count: p.int32(), 1676 + nested: Inner, 1677 + }), 1678 + }, { 1679 + id: 1, 1680 + label: 2, 1681 + result: { name: 3, count: 4, nested: 5 }, 1682 + }); 1683 + 1684 + type Input = p.InferInput<typeof Message>; 1685 + type Output = p.InferOutput<typeof Message>; 1686 + 1687 + // required fields are required 1688 + assertType<Input>({ id: 1 }); 1689 + 1690 + // optional and oneof fields can be omitted 1691 + assertType<Input>({ id: 1, label: undefined, result: undefined }); 1692 + 1693 + // oneof produces discriminated union 1694 + assertType<Input>({ id: 1, result: { case: 'name', value: 'hello' } }); 1695 + assertType<Input>({ id: 1, result: { case: 'count', value: 42 } }); 1696 + assertType<Input>({ id: 1, result: { case: 'nested', value: { value: 1 } } }); 1697 + 1698 + // output types match input 1699 + assertType<Output>({ id: 1 }); 1700 + assertType<Output>({ id: 1, result: { case: 'name', value: 'hello' } }); 1701 + assertType<Output>({ id: 1, result: { case: 'count', value: 42 } }); 1702 + assertType<Output>({ id: 1, result: { case: 'nested', value: { value: 1 } } }); 1703 + 1704 + // @ts-expect-error — wrong value type for variant 1705 + assertType<Input>({ id: 1, result: { case: 'name', value: 123 } }); 1706 + 1707 + // @ts-expect-error — invalid case name 1708 + assertType<Input>({ id: 1, result: { case: 'unknown', value: 'x' } }); 1709 + 1710 + // @ts-expect-error — missing required field 1711 + assertType<Input>({ result: { case: 'name', value: 'hello' } }); 1712 + }); 1713 + 1714 + // #endregion
+165 -22
lib/mod.ts
··· 1348 1348 return schema.type === 'optional'; 1349 1349 }; 1350 1350 1351 + // #region Oneof schema 1352 + 1353 + type InferOneofInput<TVariants extends Record<string, BaseSchema>> = 1354 + { [K in keyof TVariants & string]: { case: K; value: InferInput<TVariants[K]> } }[keyof TVariants & string]; 1355 + 1356 + type InferOneofOutput<TVariants extends Record<string, BaseSchema>> = 1357 + { [K in keyof TVariants & string]: { case: K; value: InferOutput<TVariants[K]> } }[keyof TVariants & string]; 1358 + 1359 + export interface OneofSchema<TVariants extends Record<string, BaseSchema> = Record<string, BaseSchema>> { 1360 + readonly kind: 'oneof'; 1361 + readonly variants: Readonly<TVariants>; 1362 + 1363 + readonly [kObjectType]?: { in: InferOneofInput<TVariants>; out: InferOneofOutput<TVariants> }; 1364 + } 1365 + 1366 + /** 1367 + * creates a oneof field schema where at most one variant can be set at a time 1368 + * @param variants record of variant names to their schemas 1369 + * @returns oneof field schema 1370 + */ 1371 + // #__NO_SIDE_EFFECTS__ 1372 + export const oneof = <TVariants extends Record<string, BaseSchema>>( 1373 + variants: TVariants, 1374 + ): OneofSchema<TVariants> => { 1375 + return { 1376 + kind: 'oneof', 1377 + variants, 1378 + }; 1379 + }; 1380 + 1381 + const isOneofSchema = (schema: any): schema is OneofSchema => { 1382 + return schema !== null && typeof schema === 'object' && schema.kind === 'oneof'; 1383 + }; 1384 + 1385 + // #endregion 1386 + 1351 1387 // #region Message schema 1352 1388 1353 1389 export type LooseMessageShape = Record<string, any>; 1354 - export type MessageShape = Record<string, BaseSchema>; 1390 + export type MessageShape = Record<string, BaseSchema | OneofSchema>; 1391 + 1392 + type InferFieldInput<T> = T extends OneofSchema<infer V> 1393 + ? InferOneofInput<V> 1394 + : T extends BaseSchema 1395 + ? InferInput<T> 1396 + : never; 1397 + 1398 + type InferFieldOutput<T> = T extends OneofSchema<infer V> 1399 + ? InferOneofOutput<V> 1400 + : T extends BaseSchema 1401 + ? InferOutput<T> 1402 + : never; 1355 1403 1356 1404 export type OptionalObjectInputKeys<TShape extends MessageShape> = { 1357 - [Key in keyof TShape]: TShape[Key] extends OptionalSchema<any, any> ? Key : never; 1405 + [Key in keyof TShape]: TShape[Key] extends OptionalSchema<any, any> ? Key 1406 + : TShape[Key] extends OneofSchema<any> ? Key 1407 + : never; 1358 1408 }[keyof TShape]; 1359 1409 1360 1410 export type OptionalObjectOutputKeys<TShape extends MessageShape> = { 1361 1411 [Key in keyof TShape]: TShape[Key] extends OptionalSchema<any, infer Default> 1362 1412 ? undefined extends Default ? Key 1363 1413 : never 1414 + : TShape[Key] extends OneofSchema<any> ? Key 1364 1415 : never; 1365 1416 }[keyof TShape]; 1366 1417 1367 1418 type InferMessageInput<TShape extends MessageShape> = Flatten< 1368 1419 & { 1369 - -readonly [Key in Exclude<keyof TShape, OptionalObjectInputKeys<TShape>>]: InferInput< 1420 + -readonly [Key in Exclude<keyof TShape, OptionalObjectInputKeys<TShape>>]: InferFieldInput< 1370 1421 TShape[Key] 1371 1422 >; 1372 1423 } 1373 1424 & { 1374 - -readonly [Key in OptionalObjectInputKeys<TShape>]?: InferInput<TShape[Key]>; 1425 + -readonly [Key in OptionalObjectInputKeys<TShape>]?: InferFieldInput<TShape[Key]>; 1375 1426 } 1376 1427 >; 1377 1428 1378 1429 type InferMessageOutput<TShape extends MessageShape> = Flatten< 1379 1430 & { 1380 - -readonly [Key in Exclude<keyof TShape, OptionalObjectOutputKeys<TShape>>]: InferOutput< 1431 + -readonly [Key in Exclude<keyof TShape, OptionalObjectOutputKeys<TShape>>]: InferFieldOutput< 1381 1432 TShape[Key] 1382 1433 >; 1383 1434 } 1384 1435 & { 1385 - -readonly [Key in OptionalObjectOutputKeys<TShape>]?: InferOutput<TShape[Key]>; 1436 + -readonly [Key in OptionalObjectOutputKeys<TShape>]?: InferFieldOutput<TShape[Key]>; 1386 1437 } 1387 1438 >; 1388 1439 1389 1440 export interface MessageSchema< 1390 1441 TShape extends LooseMessageShape = LooseMessageShape, 1391 - TTags extends Record<keyof TShape, number> = Record<keyof TShape, number>, 1442 + TTags extends Record<keyof TShape, number | Record<string, number>> = Record<keyof TShape, number | Record<string, number>>, 1392 1443 > extends BaseSchema<Record<string, unknown>> { 1393 1444 readonly type: 'message'; 1394 1445 readonly wire: 2; ··· 1416 1467 missingIssue: IssueTree; 1417 1468 } 1418 1469 1470 + interface OneofVariant { 1471 + schema: BaseSchema; 1472 + tag: number; 1473 + wire: WireType; 1474 + } 1475 + 1476 + interface OneofGroup { 1477 + key: string; 1478 + schema: OneofSchema; 1479 + variants: Record<string, OneofVariant>; 1480 + } 1481 + 1419 1482 const ISSUE_MISSING: IssueLeaf = { 1420 1483 ok: false, 1421 1484 code: 'missing_value', ··· 1436 1499 * @returns structured message schema 1437 1500 */ 1438 1501 // #__NO_SIDE_EFFECTS__ 1439 - export const message = <TShape extends LooseMessageShape, const TTags extends Record<keyof TShape, number>>( 1502 + export const message = <TShape extends LooseMessageShape, const TTags extends Record<keyof TShape, number | Record<string, number>>>( 1440 1503 shape: TShape, 1441 1504 tags: TTags, 1442 1505 ): MessageSchema<TShape, TTags> => { 1443 - const resolvedEntries = lazy((): Record<number, MessageEntry> => { 1444 - const resolved: Record<number, MessageEntry> = {}; 1506 + const resolvedEntries = lazy((): { 1507 + decode: Record<number, MessageEntry>; 1508 + encode: Record<number, MessageEntry>; 1509 + oneofs: OneofGroup[]; 1510 + } => { 1511 + const decode: Record<number, MessageEntry> = {}; 1512 + const encode: Record<number, MessageEntry> = {}; 1513 + const oneofs: OneofGroup[] = []; 1445 1514 const obj = shape as MessageShape; 1446 1515 1447 1516 for (const key in obj) { 1448 1517 const schema = obj[key]; 1449 - const tag = tags[key]; 1518 + const tag = (tags as any)[key]; 1519 + 1520 + if (isOneofSchema(schema)) { 1521 + const variantTags = tag as Record<string, number>; 1522 + const group: OneofGroup = { key, schema, variants: {} }; 1523 + 1524 + for (const variantKey in schema.variants) { 1525 + const variantSchema = schema.variants[variantKey]; 1526 + const variantTag = variantTags[variantKey]; 1527 + 1528 + group.variants[variantKey] = { 1529 + schema: variantSchema, 1530 + tag: variantTag, 1531 + wire: variantSchema.wire, 1532 + }; 1533 + 1534 + decode[variantTag] = { 1535 + key: key, 1536 + schema: { 1537 + kind: 'schema', 1538 + type: 'oneof_variant', 1539 + wire: variantSchema.wire, 1540 + '~decode'(state) { 1541 + const result = variantSchema['~decode'](state); 1542 + if (!result.ok) return result; 1543 + return { ok: true, value: { case: variantKey, value: result.value } }; 1544 + }, 1545 + '~encode'() {}, 1546 + } as BaseSchema, 1547 + tag: variantTag, 1548 + wire: variantSchema.wire, 1549 + optional: true, 1550 + repeated: false, 1551 + packed: false, 1552 + wireIssue: prependPath(key, { ok: false, code: 'invalid_wire', expected: variantSchema.wire }), 1553 + missingIssue: prependPath(key, ISSUE_MISSING), 1554 + }; 1555 + } 1556 + 1557 + oneofs.push(group); 1558 + continue; 1559 + } 1450 1560 1451 - let innerSchema = schema; 1561 + let innerSchema = schema as BaseSchema; 1452 1562 1453 1563 const isOptional = isOptionalSchema(innerSchema); 1454 1564 if (isOptional) { ··· 1461 1571 innerSchema = (innerSchema as RepeatedSchema).item; 1462 1572 } 1463 1573 1464 - resolved[tag] = { 1574 + const entry: MessageEntry = { 1465 1575 key: key, 1466 - schema: schema, 1576 + schema: schema as BaseSchema, 1467 1577 tag: tag, 1468 - wire: schema.wire, 1578 + wire: (schema as BaseSchema).wire, 1469 1579 optional: isOptional, 1470 1580 repeated: isRepeated, 1471 1581 packed: isPacked, 1472 - wireIssue: prependPath(key, { ok: false, code: 'invalid_wire', expected: schema.wire }), 1582 + wireIssue: prependPath(key, { ok: false, code: 'invalid_wire', expected: (schema as BaseSchema).wire }), 1473 1583 missingIssue: prependPath(key, ISSUE_MISSING), 1474 1584 }; 1585 + 1586 + decode[tag] = entry; 1587 + encode[tag] = entry; 1475 1588 } 1476 1589 1477 - return resolved; 1590 + return { decode, encode, oneofs }; 1478 1591 }); 1479 1592 1480 1593 return { ··· 1485 1598 get shape() { 1486 1599 // if we just return the shape as is then it wouldn't be the same exact 1487 1600 // shape when getters are present. 1488 - const resolved = resolvedEntries.value; 1601 + const { encode, oneofs } = resolvedEntries.value; 1489 1602 const obj: any = {}; 1490 1603 1491 - for (const index in resolved) { 1492 - const entry = resolved[index]; 1604 + for (const index in encode) { 1605 + const entry = encode[index]; 1493 1606 obj[entry.key] = entry.schema; 1494 1607 } 1495 1608 1609 + for (let i = 0; i < oneofs.length; i++) { 1610 + obj[oneofs[i].key] = oneofs[i].schema; 1611 + } 1612 + 1496 1613 return lazyProperty(this, 'shape', obj as TShape); 1497 1614 }, 1498 1615 1499 1616 get '~~decode'() { 1500 - const shape = resolvedEntries.value; 1617 + const shape = resolvedEntries.value.decode; 1501 1618 const len = Object.keys(shape).length; 1502 1619 1503 1620 const decoder: Decoder = (state) => { ··· 1657 1774 return lazyProperty(this, '~decode', decoder); 1658 1775 }, 1659 1776 get '~~encode'() { 1660 - const shape = resolvedEntries.value; 1777 + const { encode: shape, oneofs } = resolvedEntries.value; 1661 1778 1662 1779 const encoder: Encoder = (state, input) => { 1663 1780 if (typeof input !== 'object' || input === null || Array.isArray(input)) { ··· 1721 1838 if (result) { 1722 1839 return prependPath(key, result); 1723 1840 } 1841 + } 1842 + } 1843 + 1844 + for (let i = 0; i < oneofs.length; i++) { 1845 + const group = oneofs[i]; 1846 + const fieldValue = obj[group.key] as { case: string; value: unknown } | undefined; 1847 + 1848 + if (fieldValue === undefined) { 1849 + continue; 1850 + } 1851 + 1852 + if (typeof fieldValue !== 'object' || fieldValue === null) { 1853 + return prependPath(group.key, OBJECT_TYPE_ISSUE); 1854 + } 1855 + 1856 + const variant = group.variants[fieldValue.case]; 1857 + 1858 + if (!variant) { 1859 + continue; 1860 + } 1861 + 1862 + writeVarint(state, (variant.tag << 3) | variant.wire); 1863 + const result = variant.schema['~encode'](state, fieldValue.value); 1864 + 1865 + if (result) { 1866 + return prependPath(group.key, result); 1724 1867 } 1725 1868 } 1726 1869 };