Skip to content

Commit b3db46d

Browse files
committed
feat(typed): custom transformers for typed converters/validators
1 parent 564ab12 commit b3db46d

File tree

6 files changed

+610
-7
lines changed

6 files changed

+610
-7
lines changed

README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,102 @@ console.log('Modified representation data:', JSON.stringify(newData))
169169
// ["Home",460,250,[[1,32],[1,30],[1,30],[2,10],[2,11],[3,140],[4,230],[4,200],[2,5]]]
170170
```
171171

172+
### Custom Transforms
173+
174+
The `typed.js` module also supports custom transforms for specific types. This allows you to handle custom encoding/decoding logic when the wire format differs from the native JavaScript representation (e.g., base64-encoded bytes as strings, bigints as strings in JSON-RPC).
175+
176+
```js
177+
import { fromDSL } from '@ipld/schema/from-dsl.js'
178+
import { create } from '@ipld/schema/typed.js'
179+
180+
// Define a schema with custom types
181+
const schema = fromDSL(`
182+
type Base64Bytes bytes
183+
type StringBigInt int
184+
type Transaction struct {
185+
id Base64Bytes
186+
amount StringBigInt
187+
}
188+
`)
189+
190+
// Define custom transforms for the types
191+
const customTransforms = {
192+
Base64Bytes: {
193+
// Convert base64 string to Uint8Array
194+
toTyped: (obj) => {
195+
if (typeof obj !== 'string') return undefined
196+
try {
197+
// Decode base64 to Uint8Array
198+
const binary = atob(obj)
199+
const bytes = new Uint8Array(binary.length)
200+
for (let i = 0; i < binary.length; i++) {
201+
bytes[i] = binary.charCodeAt(i)
202+
}
203+
return bytes
204+
} catch {
205+
return undefined
206+
}
207+
},
208+
// Convert Uint8Array to base64 string
209+
toRepresentation: (obj) => {
210+
if (!(obj instanceof Uint8Array)) return undefined
211+
// Encode Uint8Array to base64
212+
let binary = ''
213+
for (let i = 0; i < obj.length; i++) {
214+
binary += String.fromCharCode(obj[i])
215+
}
216+
return btoa(binary)
217+
}
218+
},
219+
StringBigInt: {
220+
// Convert string to BigInt
221+
toTyped: (obj) => {
222+
if (typeof obj !== 'string') return undefined
223+
try {
224+
return BigInt(obj)
225+
} catch {
226+
return undefined
227+
}
228+
},
229+
// Convert BigInt to string
230+
toRepresentation: (obj) => {
231+
if (typeof obj !== 'bigint') return undefined
232+
return obj.toString()
233+
}
234+
}
235+
}
236+
237+
// Create typed converter with custom transforms
238+
const { toTyped, toRepresentation } = create(schema, 'Transaction', { customTransforms })
239+
240+
// Convert wire format to typed format
241+
const wireData = {
242+
id: 'SGVsbG8gV29ybGQ=', // "Hello World" in base64
243+
amount: '123456789012345678901234567890'
244+
}
245+
246+
const typed = toTyped(wireData)
247+
console.log('Typed:', typed)
248+
// → Typed: {
249+
// id: Uint8Array(11) [...], // decoded bytes
250+
// amount: 123456789012345678901234567890n
251+
// }
252+
253+
// Convert back to wire format
254+
const repr = toRepresentation(typed)
255+
console.log('Representation:', repr)
256+
// → Representation: {
257+
// id: 'SGVsbG8gV29ybGQ=',
258+
// amount: '123456789012345678901234567890'
259+
// }
260+
```
261+
262+
Custom transforms can be provided as either:
263+
- Function objects (will be converted to source code for the generated validators)
264+
- String containing the function body (useful for code generation contexts)
265+
266+
If only `toTyped` is provided, `toRepresentation` defaults to the identity function (returns input unchanged).
267+
172268
## Command line
173269

174270
**@ipld/schema also exports an executable**: if installed with `-g` you will get an `ipld-schema` command in your `PATH`.

lib/typed.js

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
* @typedef {import('../schema-schema').TypeName} TypeName
1212
* @typedef {import('../schema-schema').TypeNameOrInlineDefn} TypeNameOrInlineDefn
1313
* @typedef {(obj:any)=>undefined|any} TypeTransformerFunction
14+
* @typedef {{ toTyped: string | ((value: any) => any | undefined), toRepresentation?: string | ((value: any) => any | undefined) }} CustomTransform
15+
* @typedef {{ [typeName: string]: CustomTransform }} CustomTransforms
16+
* @typedef {{ customTransforms?: CustomTransforms }} TypedOptions
1417
*/
1518

1619
const safeNameRe = /^\$*[a-z][a-z0-9_]*$/i
@@ -149,14 +152,15 @@ function tc (s) {
149152
/**
150153
* @param {Schema} schema
151154
* @param {string} root
155+
* @param {TypedOptions} [options]
152156
* @returns {{ toTyped: TypeTransformerFunction, toRepresentation: TypeTransformerFunction }}
153157
*/
154-
export function create (schema, root) {
158+
export function create (schema, root, options = {}) {
155159
if (!root || typeof root !== 'string') {
156160
throw new TypeError('A root is required')
157161
}
158162

159-
const builder = new Builder(schema)
163+
const builder = new Builder(schema, options)
160164
builder.addType(root)
161165

162166
let fn = '\'use strict\'\n'
@@ -172,8 +176,9 @@ export function create (schema, root) {
172176
export class Builder {
173177
/**
174178
* @param {Schema} schema
179+
* @param {TypedOptions} [options]
175180
*/
176-
constructor (schema) {
181+
constructor (schema, options = {}) {
177182
if (!schema || typeof schema.types !== 'object') {
178183
throw new TypeError('Invalid schema definition')
179184
}
@@ -187,6 +192,9 @@ export class Builder {
187192
this.typeTransformers = {}
188193
/** @type {Record<string, string>} */
189194
this.reprTransformers = {}
195+
196+
/** @type {CustomTransforms} */
197+
this.customTransforms = options.customTransforms || {}
190198
}
191199

192200
dumpTypeTransformers () {
@@ -211,6 +219,35 @@ export class Builder {
211219
return
212220
}
213221

222+
// Check for custom transforms first
223+
if (this.customTransforms[typeName]) {
224+
const transform = this.customTransforms[typeName]
225+
226+
// Handle string function bodies or actual functions
227+
if (typeof transform.toTyped === 'function') {
228+
// Store the function source for code generation
229+
this.typeTransformers[typeName] = transform.toTyped.toString()
230+
} else if (typeof transform.toTyped === 'string') {
231+
this.typeTransformers[typeName] = transform.toTyped
232+
} else {
233+
throw new TypeError(`Invalid toTyped transform for type "${typeName}"`)
234+
}
235+
236+
// Handle toRepresentation (defaults to toTyped if not provided)
237+
if (transform.toRepresentation) {
238+
if (typeof transform.toRepresentation === 'function') {
239+
this.reprTransformers[typeName] = transform.toRepresentation.toString()
240+
} else if (typeof transform.toRepresentation === 'string') {
241+
this.reprTransformers[typeName] = transform.toRepresentation
242+
}
243+
} else {
244+
// Default toRepresentation to return the input unchanged (identity function)
245+
this.reprTransformers[typeName] = '/** @returns {undefined|any} */ (/** @type {any} */ obj) => obj'
246+
}
247+
248+
return
249+
}
250+
214251
if (typeName === '$$Any') {
215252
// special case for $$Any because it's a recursive definition, so we set up a dummy in place so
216253
// any recursive attempt to add finds a definition before it's set

0 commit comments

Comments
 (0)